All files / web/src/app/api/curriculum/[playerId]/sessions/history route.ts

0% Statements 0/133
0% Branches 0/1
0% Functions 0/1
0% Lines 0/133

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134                                                                                                                                                                                                                                                                           
/**
 * API route for paginated session history with cursor-based pagination
 *
 * GET /api/curriculum/[playerId]/sessions/history
 *   Query params:
 *   - cursor: Session ID to start after (optional, for pagination)
 *   - limit: Number of sessions to return (default: 20, max: 100)
 *
 * Response:
 *   {
 *     sessions: PracticeSession[],
 *     nextCursor: string | null,  // ID of last session, null if no more
 *     hasMore: boolean
 *   }
 */

import { NextResponse } from 'next/server'
import { and, desc, eq, lt, ne } from 'drizzle-orm'
import { db } from '@/db'
import { sessionPlans } from '@/db/schema/session-plans'
import { withAuth } from '@/lib/auth/withAuth'
import { canPerformAction } from '@/lib/classroom'
import { getUserId } from '@/lib/viewer'

export const GET = withAuth(async (request, { params }) => {
  const routeStart = performance.now()
  const timings: Record<string, number> = {}

  try {
    const { playerId } = (await params) as { playerId: string }
    const { searchParams } = new URL(request.url)

    const cursor = searchParams.get('cursor')
    const limitParam = searchParams.get('limit')
    const limit = Math.min(Math.max(parseInt(limitParam ?? '20', 10) || 20, 1), 100)
    // Soft-deleted sessions (#158) are hidden by default. This is the ONE
    // status reader that filters on `completedAt` rather than a positive
    // status allow-list, so it needs an explicit `status != 'deleted'` guard.
    // `?includeDeleted=1` flips it to return ONLY deleted rows, powering the
    // collapsed "N removed sessions" reveal with Restore.
    const includeDeleted = searchParams.get('includeDeleted') === '1'

    if (!playerId) {
      return NextResponse.json({ error: 'Player ID required' }, { status: 400 })
    }

    // Authorization check
    let t = performance.now()
    const userId = await getUserId()
    const canView = await canPerformAction(userId, playerId, 'view')
    timings.auth = performance.now() - t

    if (!canView) {
      return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
    }

    // Build query conditions
    const conditions = [
      eq(sessionPlans.playerId, playerId),
      // Only include completed sessions
      // completedAt is not null check done via ordering
      includeDeleted
        ? eq(sessionPlans.status, 'deleted')
        : ne(sessionPlans.status, 'deleted'),
    ]

    // If cursor provided, get sessions older than the cursor session
    if (cursor) {
      t = performance.now()
      // Get the cursor session's completedAt to paginate from there
      const cursorSession = await db.query.sessionPlans.findFirst({
        where: eq(sessionPlans.id, cursor),
        columns: { completedAt: true },
      })
      timings.cursorLookup = performance.now() - t

      if (cursorSession?.completedAt) {
        conditions.push(lt(sessionPlans.completedAt, cursorSession.completedAt))
      }
    }

    // Fetch limit + 1 to check if there are more
    t = performance.now()
    const sessions = await db.query.sessionPlans.findMany({
      where: and(...conditions),
      orderBy: [desc(sessionPlans.completedAt)],
      limit: limit + 1,
    })
    timings.dbQuery = performance.now() - t

    // Filter to only completed sessions and check for more
    t = performance.now()
    const completedSessions = sessions.filter((s) => s.completedAt !== null)
    const hasMore = completedSessions.length > limit
    const returnSessions = completedSessions.slice(0, limit)

    // Transform to match PracticeSession interface expected by client
    const transformedSessions = returnSessions.map((session) => {
      const results = session.results
      return {
        id: session.id,
        playerId: session.playerId,
        startedAt: session.startedAt,
        completedAt: session.completedAt,
        // Present only on the includeDeleted view; powers the Restore affordance.
        deletedAt: session.deletedAt,
        problemsAttempted: results.length,
        problemsCorrect: results.filter((r) => r.isCorrect).length,
        totalTimeMs: results.reduce((sum, r) => sum + (r.responseTimeMs ?? 0), 0),
        skillsUsed: [...new Set(results.flatMap((r) => r.skillsExercised ?? []))],
      }
    })
    timings.transform = performance.now() - t

    const total = performance.now() - routeStart
    console.log(
      `[PERF] /api/curriculum/.../sessions/history: ${total.toFixed(1)}ms | ` +
        `auth=${timings.auth.toFixed(1)}ms, ` +
        `db=${timings.dbQuery.toFixed(1)}ms, ` +
        `transform=${timings.transform.toFixed(1)}ms | ` +
        `sessions=${returnSessions.length}`
    )

    return NextResponse.json({
      sessions: transformedSessions,
      nextCursor: hasMore ? returnSessions[returnSessions.length - 1]?.id : null,
      hasMore,
    })
  } catch (error) {
    console.error('Error fetching session history:', error)
    return NextResponse.json({ error: 'Failed to fetch session history' }, { status: 500 })
  }
})